Skip to content

Cleanup

Video Summary

We've been learning how to start long-running processes on mount with the useEffect hook, but our solutions so far have been incomplete!

To show the problem, we've updated our mouse-tracking example from the “Running On Mount” lesson so that it's conditionally rendered:

function App() {
const [isTrackingMouse, setIsTrackingMouse] = React.useState(true);
function toggleMouseTracking() {
setIsTrackingMouse(!isTrackingMouse);
}
return (
<div className="wrapper">
<button onClick={toggleMouseTracking}>
Toggle Mouse Tracking
</button>
{isTrackingMouse && <MouseTracker />}
</div>
);
}

When isTrackingMouse is a truthy value, we mount the component. As we learned in the “Component Instances” lesson, this creates a component instance, a place for us to store data related to the component.

When isTrackingMouse is falsy, however, we unmount the component, destroying the instance and removing all associated DOM nodes.

It's intuitive to think, therefore, that any lingering effects will also be interrupted if the component unmounts, but unfortunately, it doesn't work this way.

Our MouseTracker component sets up the following effect:

React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
}, []);

When the component unmounts, the event listener remains, tracking the cursor position and calling setMousePosition on a component instance that shouldn't exist anymore!

This is a problem for 2 reasons:

  1. Every time the component is re-mounted, another event listener will be added, without the previous one being removed.
  2. Because we're referencing a part of the component instance (by calling setMousePosition), the JavaScript garbage collector isn't able to clean up this instance! That means that every time we mount this component, we create an instance that will never be erased.

This is known as a memory leak. The longer the person spends using our application, the more memory it will consume. The memory will be released if the user refreshes the page, but often people will leave the same tab running for weeks or months on end!

Why isn't the event listener removed automatically? Here's the thing: React actually has no idea what goes on inside our effect function:

// What React sees:
React.useEffect(() => {
????
}, []);

React sees that we've given it a function, and we've specified when it should be called (with the dependency array), but React can't “see inside” this function! It has no idea that we've started an event listener, since the event-listener stuff is part of the DOM.

Similarly, the JavaScript engine that runs our event listener has no idea that it was created within a component instance, and that it should be contingent on that instance existing.

Essentially, we have two independent systems here, and it's up to us to synchronize them.

Fortunately, the useEffect comes with a tool to make this possible: cleanup functions.

Here's what it looks like:

React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
}
}, []);

Within our effect function, we return a function that contains the cleanup work to be done. React will hang onto this function, and invoke it at the appropriate time: right before the component unmounts.

This is typically the pattern for any subscription / long-running process. We subscribe in the effect function, and unsubscribe in the cleanup function. React will call the cleanup function right before the component unmounts, stopping the process and ensuring we don't wind up with a memory leak.

In the last module, we talked about component instances, and how conditionally rendering components will create and destroy these instances. If you're still feeling fuzzy on this concept, you can review the “Component Instances” lesson from Module 2.

Here's the sandbox from the video, with the cleanup function added:

Code Playground

import React from 'react';

function MouseTracker() {
const [mousePosition, setMousePosition] = React.useState({
x: 0,
y: 0,
});

React.useEffect(() => {
// Effect logic:
function handleMouseMove(event) {
console.log('move');
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}

window.addEventListener('mousemove', handleMouseMove);

// Cleanup function:
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);

return (
<p>
{mousePosition.x} / {mousePosition.y}
preview
console

Cleanup with dependencies

Video Summary

In the example above, we saw how to start and stop a long-running effect, tied to the component lifecycle. But what if our effect has dependencies? How do they interact with this cleanup function?

Let's look at an updated example. Instead of having a parent component that mounts/unmounts the MouseTracker component, what if everything happens within the component?

Here's the new code:

import React from 'react';
function MouseTracker() {
const [mousePosition, setMousePosition] = React.useState({
x: 0,
y: 0,
});
const [isEnabled, setIsEnabled] = React.useState(true);
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
function toggleMouseTracking() {
setIsEnabled(!isEnabled);
}
return (
<>
<button onClick={toggleMouseTracking}>
Toggle Mouse Tracking
</button>
<p>
{mousePosition.x} / {mousePosition.y}
</p>
</>
);
}
export default MouseTracker;

Essentially, I want to register the event listener when isEnabled is true, and unregister it when the user clicks the button, flipping isEnabled to false.

When I was first getting started with effects, my intuition was to do something like this:

if (isEnabled) {
React.useEffect(() => {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
}

It made so much sense to me, to conditionally call the useEffect hook. Unfortunately, as we've learned, this violates the “Rules of Hooks”. We're not allowed to conditionally call any hooks.

Well, hmm. What if we move the if condition within the hook, like this?

React.useEffect(() => {
if (isEnabled) {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}
}, []);

If we run this code, we get a different lint warning: we're missing a dependency! Like we saw in the Effect Lint Rules lesson, we need to add all state variables to the dependency array.

When we add isEnabled to the dependency array, we solve all of the lint warnings... and we also solve the problem!

// ✅ This code does exactly what we want!
React.useEffect(() => {
if (isEnabled) {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}
}, [isEnabled]);

How exactly does this work, though?

(This part is hard to summarize, I suggest watching / re-watching the video!)

  • When the component mounts, we run the effect, registering the event listener, and handing React the cleanup function, like a gift waiting to be opened
  • As the user moves the mouse around, the mousePosition state will be updated rapid-fire, but the effect doesn't re-run, since mousePosition isn't a dependency.
  • If the user clicks the button, isEnabled flips to false. Since isEnabled is a dependency, it means the effect will re-run.
  • First, though, React invokes the cleanup function!
  • The event listener is removed, and because isEnabled is false, the effect is a no-op.
  • This process repeats every time the user clicks the button.

The key trick here is that effects aren't meant to "stack". Before React can re-run the effect, it'll invoke the cached cleanup function, to make sure we're starting from a "clean slate".

The video ends with several diagrams illustrating this concept. They can be viewed below:

Here are the diagrams from the video:

The order of operations:

A diagram showing 3 rows. Row 1, initial render: Render, then Effect. Row 2, subsequent renders: Render, then Cleanup, then Effect. Row 3, unmount: Render, then Cleanup.

A view of snapshots and cleanup:

A diagram showing how cleanup functions affect the effect from the previous render.

Cleanup functions aren't always provided:

A diagram showing how cleanup functions affect the effect from the previous render.

Finally, here's the sandbox from the video. I tweaked the button's text to make it a bit clearer:

Code Playground

import React from 'react';

function MouseTracker() {
const [mousePosition, setMousePosition] = React.useState({
x: 0,
y: 0,
});
const [isEnabled, setIsEnabled] = React.useState(true);

React.useEffect(() => {
if (isEnabled) {
function handleMouseMove(event) {
setMousePosition({
x: event.clientX,
y: event.clientY,
});
}

window.addEventListener('mousemove', handleMouseMove);

return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}
}, [isEnabled]);

function toggleMouseTracking() {
setIsEnabled(!isEnabled);
}

return (
<>
<button onClick={toggleMouseTracking}>
Mouse Tracking: {isEnabled ? 'On' : 'Off'}
</button>
<p>
{mousePosition.x} / {mousePosition.y}
</p>
</>
);
}

export default MouseTracker;
preview
console